#!/bin/env lua
assert (os.execute(), "Must have a shell")
LUAC = LUAC or os.getenv("HOME") .. '/bin/luac'
module("lualint", package.seeall)

local ignore = {}
do
	local function addignore(...)
		for i = 1, select("#", ...) do
			ignore[select(i, ...)] = true
		end
	end

	addignore("_G", "_VERSION", "assert", "collectgarbage", "error", "getfenv", "getmetatable")
	addignore("ipairs", "load", "loadstring", "next", "pairs", "pcall", "print", "rawequal")
	addignore("rawget", "rawset", "select", "setfenv", "setmetatable", "tonumber", "tostring")
	addignore("type", "unpack", "xpcall", "newproxy")
	addignore("coroutine", "math", "string", "table")

	if MODE == "WOW" then
		addignore("min", "tinsert", "max", "ceil", "strmatch", "gsub", "strsplit",
		"strupper", "tremove", "bit", "strlower", "getglobal", "setglobal",
		"issecure", "securecall", "forceinsecure",
		"format", "floor", "mod",
		"strfind", "strsub", "strlen", "strjoin", "gmatch", "strbyte", "strrep", "strrev", "cos", "deg", "exp",
		"strconcat", "strchar", "strconcat", "strtrim", "frexp", "ldexp", "log", "log10", "rad", "random",
		"sin", "tan", "date", "sqrt")
	else
		addignore("dofile", "loadfile", "module", "require", "arg")
		addignore("debug", "io", "os", "package")
	end

end

function compile(src)
	local output = src .. ".out"
	local cmd = string.format("%s -l -p  %s > %s", LUAC, src, output)
	os.execute(cmd)
	return output
end

local function strip(s)
	return s:gsub("^%s*", ""):gsub("%s*$", "")
end

function parse(list)
	local functions = {}
	local func
	for line in io.lines(list) do
		if not func then
			local name, source, start, stop, instructions, size, address = line:match("^(.*) <(.*):(%d+),(%d+)> %((%d+) instructions, (%d+) bytes at (0x[0-9a-f]+)%)$")
			if name then
				func = {
					name = name,
					source = source,
					line_start = tonumber(start),
					line_end = tonumber(stop),
					instructions = tonumber(instructions),
					size = tonumber(size),
					address = address,
					content = {},
					setglobals = {},
					missinglocals = {},
					getglobals = {},
				}
				functions[#functions + 1] = func
			end
		elseif not func.params then
			local params, slots, upvalues, locals, constants, functions = line:match("(%d+%+?) params?, (%d+) slots?, (%d+) upvalues?, (%d+) locals?, (%d+) constants?, (%d+) functions?")
			if params then
				if params:sub(-1) == "+" then
					func.varargs = true
					func.params = tonumber(params:sub(1, -2))
				else
					func.varargs = false
					func.params = tonumber(params)
				end
				func.slots = tonumber(slots)
				func.upvalues = tonumber(upvalues)
				func.locals = tonumber(locals)
				func.constants = tonumber(constants)
			end
		else
			local index, line, mnemonic, params, comments = line:match("%s+(%d+)%s+%[(%d+)%]%s+([^%s]+)%s+([^;]+)(.*)")
			if index then
				index = tonumber(index)
				comments = strip(comments)
				if comments and comments:sub(1, 1) == ";" then comments = strip(comments:sub(2)) end
				if comments and comments:len() == 0 then comments = nil end
				assert(#func.content + 1 == index)
				func.content[index] = {
					line = tonumber(line),
					mnemonic = mnemonic,
					params = strip(params),
					comments = comments,
				}
			else
				func = nil
			end
		end
	end
	return functions
end

local function update_function_names (functions)
	local main = functions[1]
	main.gname = "*MAIN*"
	if not main then return end
	assert(main.name == "main")
	for i = 2, #main.content do
		local instruction = main.content[i]
		if instruction.mnemonic == "SETGLOBAL" and instruction.comments then
			local gname = instruction.comments
			local p = i - 1
			while p > 1 and main.content[p].mnemonic == "MOVE" do p = p - 1 end
			local prev_instruction = main.content[p]
			if prev_instruction.mnemonic == "CLOSURE" then
				local address = prev_instruction.comments
				for j = 2, #functions do
					if functions[j].address == address then
						assert(functions[j].name == "function")
						functions[j].gname = gname
						break
					end
				end
			end
			ignore[gname] = true
		elseif instruction.mnemonic == "SETTABLE" and instruction.comments then
			local gname = instruction.comments:match("\"(.*)\" -")
			if gname then
				local p = i - 1
				while p > 1 and main.content[p].mnemonic == "MOVE" do p = p - 1 end
				local prev_instruction = main.content[p]
				if prev_instruction.mnemonic == "CLOSURE" then
					local address = prev_instruction.comments
					for j = 2, #functions do
						if functions[j].address == address then
							assert(functions[j].name == "function")
							functions[j].gname = ":"..gname
							break
						end
					end
				end
			end
		end
	end
end

local global_checks = {}
local function_checks = {}

function report(s, ...)
	if s:find("%", 1, true) then
		print(s:format(...))
	else
		print(s, ...)
	end
end

function lint(filename, report)
	report("INFO: checking %s", filename)
	local temp = compile(filename)
	local functions = parse(temp)
	os.remove(temp)
	update_function_names(functions)
	for _, check in ipairs(global_checks) do
		check(functions, report)
	end
	for _, check in ipairs(function_checks) do
		for _, func in ipairs(functions) do
			check(func, report, functions)
		end
	end
end

function main(...)
	for i = 1, select("#", ...) do
		lint(select(i, ...), report)
	end
end

table.insert(function_checks, function (func, report)
	if func.name == "main" then return end
	for _, inst in ipairs(func.content) do
		if inst.mnemonic == "SETGLOBAL" then
			if not func.setglobals[inst.comments] then
				func.setglobals[inst.comments] = inst.line
			end
		end
	end
end)

table.insert(function_checks, function (func, report, functions)
	for _, inst in ipairs(func.content) do
		if inst.mnemonic == "GETGLOBAL" and not ignore[inst.comments] then
			local found
			for _, info in ipairs(functions) do
				if info.gname == inst.comments then
					found = true break
				end
			end
			if not found and func.setglobals[inst.comments] and func.setglobals[inst.comments] < inst.line then
				found = true
				if not func.missinglocals[inst.comments] then
					func.missinglocals[inst.comments] = func.setglobals[inst.comments]
				end
			end
			if not found then
				if not func.getglobals[inst.comments] then
					func.getglobals[inst.comments] = inst.line
				end
			end
		end
	end
end)

table.insert(function_checks, function (func, report, functions)
	local errors = {}
	for name, line in pairs(func.setglobals) do
		if not func.missinglocals[name] then
			errors[#errors + 1] = { line = line, text = string.format("GLOBAL %q is set", name) }
		end
	end
	for name, line in pairs(func.missinglocals) do
		errors[#errors + 1] = { line = line, text = string.format("missing local declaration for %q", name) }
	end
--~ 	for name, line in pairs(func.getglobals) do
--~ 		errors[#errors + 1] = { line = line, text = string.format("GLOBAL %q is loaded", name) }
--~ 	end
	table.sort(errors, function (a, b) return a.line < b.line end)
	for _, e in ipairs(errors) do
		report("%s:%d\t%s in %s", func.source, e.line, e.text, func.gname or "*"..func.name..":"..func.line_start.."-"..func.line_end.."*")
	end
end)

table.insert(global_checks, function (funcs, report)
	local count, size = 0, 0
	for _, func in ipairs(funcs) do
		count = count + 1
		size = size + func.size
	end
	report("INFO: %d functions, %d bytes", count, size)
end)

if arg[0]:find("lualint") then
	main(unpack(arg))
end
